Skip to content

mcp: add StreamableHTTPOptions.StreamKeepAlive for periodic SSE comments on idle streams - #1232

Open
yhxlele wants to merge 6 commits into
modelcontextprotocol:mainfrom
yhxlele:stream-keepalive
Open

yhxlele wants to merge 6 commits into
modelcontextprotocol:mainfrom
yhxlele:stream-keepalive

Conversation

@yhxlele

@yhxlele yhxlele commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The 2026-07-28 Streamable HTTP spec encourages servers to periodically emit an SSE comment line on long-lived streams, in particular the subscriptions/listen response, so that idle-timeout intermediaries do not sever them. The SDK had no way to do this: the stream's ResponseWriter is private and every write goes through deliverLocked under stream.mu. Servers behind a proxy ended up sending fake notifications/resources/updated as a heartbeat instead.

This adds StreamableHTTPOptions.StreamKeepAlive time.Duration. The response stream of a subscriptions/listen request gets a goroutine that writes the bare :\n\n comment from the spec example and flushes whenever the stream has carried no bytes for that duration. Zero selects DefaultStreamKeepAlive (30s), so servers follow the spec out of the box; a negative value disables the keep-alive. This is the same convention as MaxRequestBodyBytes. Other SSE responses are not kept alive; the standalone GET stream and EventStore paths are untouched.

Semantics:

  • Idle-reset per stream. stream.lastWrite is stamped by every write in deliverLocked; the goroutine sleeps until lastWrite + interval, so a busy stream gets no comments and a quiet one gets exactly one per interval.
  • Nothing is written before the listen acknowledgment. deliverLocked may still need to set a 400/404 status for a SEP-2575 error, which requires uncommitted headers, so the goroutine parks on a committed channel that the first write closes. The acknowledgment arrives within milliseconds, so this costs nothing.
  • Writes happen under stream.mu, so a comment can never interleave with an event.
  • A failed write closes the stream's done channel: hangResponse returns, the request context is cancelled, and the listen handler unwinds and unsubscribes. A dead peer is therefore noticed within one interval rather than at the next real notification.
  • X-Accel-Buffering: no is set on SSE POST responses, which the same spec section recommends; the header is dropped again on the JSON error-override path.

Tests: a raw listen POST sees the ack and then only comments; a slow tools/call gets no comment at all; the option resolution (zero, negative, explicit, nil options) is checked; the goroutine semantics (park, idle-reset, failed write closes the stream) are tested directly; keep-alive goroutines end with their streams; and an end-to-end test puts an idle-timeout proxy in front of the server and checks that a quiet subscription dies with the keep-alive disabled and survives with it, with the SDK client ignoring the comments.

Fixes #1229

@yhxlele

yhxlele commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Verified end to end against a real deployment rather than only httptest: a Go server on this branch with StreamKeepAlive: 30 * time.Second, its own periodic notification heartbeat disabled, one raw subscriptions/listen POST observing wire lines, and a go-sdk v1.7.0 client subscribed to the same resource. After a quiet period a database update fires the server's real change notification.

Edge in front of the server Quiet period Wire Delivery after the update
Traefik (no idle timeout) 150s ack, then : keepalive at +30.0s, +60.0s, +90.0s, +120.0s, +150.0s; no other bytes 326ms, stream still open
nginx proxy_read_timeout 45s 100s ack, then : keepalive at +30s, +60s, +90s 279ms, stream still open
nginx proxy_read_timeout 20s (control, shorter than the interval) ack only; nginx closed the stream at +21.5s (unexpected EOF) none: the subscription was gone

So the comment resets a real proxy's read timer, the idle-reset spacing is exact (each comment lands interval after the previous byte, not on a fixed clock), the go-sdk client ignores the comments and keeps working, and without bytes the same proxy drops a quiet listen stream before the first interval. nginx also honoured and stripped X-Accel-Buffering: no (present on the direct connection, absent behind nginx), as expected.

@guglielmo-san

Copy link
Copy Markdown
Contributor

@yhxlele can you target for now only the subscriptions/listen case in the PR for now, discarding what is related to deprecated features (GET stream, EventStore..)

@yhxlele

yhxlele commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Sure, deprecated feature related modification discarded.

Comment thread mcp/streamable.go Outdated
Comment on lines +1135 to +1136
}
_, err := fmt.Fprint(s.w, ": keepalive\n\n")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove the keepalive to keep it matching with the example in the spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure

Comment thread mcp/streamable.go
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcp: add StreamableHTTPOptions.StreamKeepAlive — the SSE keep-alive comment the 2026-07-28 spec encourages for subscriptions/listen

2 participants